// Copyright 2020-2023 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::io;
use std::io::Write as _;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use indoc::writedoc;
use itertools::Itertools as _;
use jj_lib::file_util;
use jj_lib::git;
use jj_lib::git::GitRefKind;
use jj_lib::git::GitSettings;
use jj_lib::git::parse_git_ref;
use jj_lib::repo::ReadonlyRepo;
use jj_lib::repo::Repo as _;
use jj_lib::view::View;
use jj_lib::workspace::Workspace;

use super::write_repository_level_trunk_alias;
use crate::cli_util::CommandHelper;
use crate::cli_util::WorkspaceCommandHelper;
use crate::cli_util::start_repo_transaction;
use crate::command_error::CommandError;
use crate::command_error::cli_error;
use crate::command_error::internal_error;
use crate::command_error::user_error_with_hint;
use crate::command_error::user_error_with_message;
use crate::commands::git::maybe_add_gitignore;
use crate::formatter::FormatterExt as _;
use crate::git_util::is_colocated_git_workspace;
use crate::git_util::print_git_export_stats;
use crate::git_util::print_git_import_stats;
use crate::ui::Ui;

/// Create a new Git backed repo.
#[derive(clap::Args, Clone, Debug)]
pub struct GitInitArgs {
    /// The destination directory where the `jj` repo will be created.
    /// If the directory does not exist, it will be created.
    /// If no directory is given, the current directory is used.
    ///
    /// By default the `git` repo is under `$destination/.jj`
    #[arg(default_value = ".", value_hint = clap::ValueHint::DirPath)]
    destination: String,

    /// Colocate the Jujutsu repo with the git repo
    ///
    /// Specifies that the `jj` repo should also be a valid `git` repo, allowing
    /// the use of both `jj` and `git` commands in the same directory.
    ///
    /// The repository will contain a `.git` dir in the top-level. Regular Git
    /// tools will be able to operate on the repo.
    ///
    /// **This is the default**, and this option has no effect, unless the
    /// [git.colocate config] is set to `false`.
    ///
    /// This option is mutually exclusive with `--git-repo`.
    ///
    /// [git.colocate config]:
    ///     https://docs.jj-vcs.dev/latest/config/#default-colocation
    #[arg(long, conflicts_with = "git_repo")]
    colocate: bool,

    /// Disable colocation of the Jujutsu repo with the git repo
    ///
    /// Prevent Git tools that are unaware of `jj` and regular Git commands from
    /// operating on the repo. The Git repository that stores most of the repo
    /// data will be hidden inside a sub-directory of the `.jj` directory.
    ///
    /// See [colocation docs] for some minor advantages of non-colocated
    /// workspaces.
    ///
    /// [colocation docs]:
    ///     https://docs.jj-vcs.dev/latest/git-compatibility/#colocated-jujutsugit-repos
    #[arg(long, conflicts_with = "colocate")]
    no_colocate: bool,

    /// Specifies a path to an **existing** git repository to be
    /// used as the backing git repo for the newly created `jj` repo.
    ///
    /// If the specified `--git-repo` path happens to be the same as
    /// the `jj` repo path (both .jj and .git directories are in the
    /// same working directory), then both `jj` and `git` commands
    /// will work on the same repo. This is called a colocated workspace.
    ///
    /// This option is mutually exclusive with `--colocate`.
    #[arg(long, conflicts_with = "colocate", value_hint = clap::ValueHint::DirPath)]
    git_repo: Option<String>,
}

pub fn cmd_git_init(
    ui: &mut Ui,
    command: &CommandHelper,
    args: &GitInitArgs,
) -> Result<(), CommandError> {
    if command.global_args().ignore_working_copy {
        return Err(cli_error("--ignore-working-copy is not respected"));
    }
    if command.global_args().at_operation.is_some() {
        return Err(cli_error("--at-op is not respected"));
    }
    let cwd = command.cwd();
    let wc_path = cwd.join(&args.destination);
    let wc_path = file_util::create_or_reuse_dir(&wc_path)
        .and_then(|_| dunce::canonicalize(wc_path))
        .map_err(|e| user_error_with_message("Failed to create workspace", e))?;

    let colocate = if command.settings().get_bool("git.colocate")? {
        !args.no_colocate
    } else {
        args.colocate
    };

    do_init(ui, command, &wc_path, colocate, args.git_repo.as_deref())?;

    let relative_wc_path = file_util::relative_path(cwd, &wc_path);
    writeln!(
        ui.status(),
        r#"Initialized repo in "{}""#,
        relative_wc_path.display()
    )?;
    if colocate {
        writeln!(
            ui.hint_default(),
            r"Running `git clean -xdf` will remove `.jj/`!",
        )?;
    }

    Ok(())
}

fn do_init(
    ui: &mut Ui,
    command: &CommandHelper,
    workspace_root: &Path,
    colocate: bool,
    git_repo: Option<&str>,
) -> Result<(), CommandError> {
    #[derive(Clone, Debug)]
    enum GitInitMode {
        Colocate,
        External(PathBuf),
        Internal,
    }

    let colocated_git_repo_path = workspace_root.join(".git");
    let init_mode = if colocate {
        if colocated_git_repo_path.exists() {
            GitInitMode::External(colocated_git_repo_path)
        } else {
            GitInitMode::Colocate
        }
    } else if let Some(path_str) = git_repo {
        let mut git_repo_path = command.cwd().join(path_str);
        if !git_repo_path.ends_with(".git") {
            git_repo_path.push(".git");
            // Undo if .git doesn't exist - likely a bare repo.
            if !git_repo_path.exists() {
                git_repo_path.pop();
            }
        }
        GitInitMode::External(git_repo_path)
    } else {
        if colocated_git_repo_path.exists() {
            return Err(user_error_with_hint(
                "Did not create a jj repo because there is an existing Git repo in this directory.",
                "To create a repo backed by the existing Git repo, run `jj git init --colocate` \
                 instead.",
            ));
        }
        GitInitMode::Internal
    };

    let settings = command.settings_for_new_workspace(workspace_root)?;
    match &init_mode {
        GitInitMode::Colocate => {
            let (workspace, repo) = Workspace::init_colocated_git(&settings, workspace_root)?;
            let workspace_command = command.for_workable_repo(ui, workspace, repo)?;
            maybe_add_gitignore(&workspace_command)?;
        }
        GitInitMode::External(git_repo_path) => {
            let (workspace, repo) =
                Workspace::init_external_git(&settings, workspace_root, git_repo_path)?;
            // Import refs first so all the reachable commits are indexed in
            // chronological order.
            let colocated = is_colocated_git_workspace(&workspace, &repo);
            let repo = init_git_refs(ui, repo, command.string_args(), colocated)?;
            let mut workspace_command = command.for_workable_repo(ui, workspace, repo)?;
            maybe_add_gitignore(&workspace_command)?;
            workspace_command.maybe_snapshot(ui)?;
            maybe_set_repository_level_trunk_alias(ui, &workspace_command)?;
            if !workspace_command.working_copy_shared_with_git() {
                let mut tx = workspace_command.start_transaction();
                jj_lib::git::import_head(tx.repo_mut())?;
                if let Some(git_head_id) = tx.repo().view().git_head().as_normal().cloned() {
                    let git_head_commit = tx.repo().store().get_commit(&git_head_id)?;
                    tx.check_out(&git_head_commit)?;
                }
                if tx.repo().has_changes() {
                    tx.finish(ui, "import git head")?;
                }
            }
            print_trackable_remote_bookmarks(ui, workspace_command.repo().view())?;
        }
        GitInitMode::Internal => {
            Workspace::init_internal_git(&settings, workspace_root)?;
        }
    }
    Ok(())
}

/// Imports branches and tags from the underlying Git repo, exports changes if
/// the repo is colocated.
///
/// This is similar to `WorkspaceCommandHelper::import_git_refs()`, but never
/// moves the Git HEAD to the working copy parent.
fn init_git_refs(
    ui: &mut Ui,
    repo: Arc<ReadonlyRepo>,
    string_args: &[String],
    colocated: bool,
) -> Result<Arc<ReadonlyRepo>, CommandError> {
    let mut git_settings = GitSettings::from_settings(repo.settings())?;
    let mut tx = start_repo_transaction(&repo, string_args);
    // There should be no old refs to abandon, but enforce it.
    git_settings.abandon_unreachable_commits = false;
    let stats = git::import_refs(tx.repo_mut(), &git_settings)?;
    print_git_import_stats(ui, tx.repo(), &stats, false)?;
    if !tx.repo().has_changes() {
        return Ok(repo);
    }
    if colocated {
        // If git.auto-local-bookmark = true or
        // remotes.<name>.auto-track-bookmarks is set, local bookmarks could be
        // created for the imported remote branches.
        let stats = git::export_refs(tx.repo_mut())?;
        print_git_export_stats(ui, &stats)?;
    }
    let repo = tx.commit("import git refs")?;
    writeln!(
        ui.status(),
        "Done importing changes from the underlying Git repo."
    )?;
    Ok(repo)
}

// Set repository level `trunk()` alias to the default branch.
// Checks "upstream" first, then "origin" as fallback.
pub fn maybe_set_repository_level_trunk_alias(
    ui: &Ui,
    workspace_command: &WorkspaceCommandHelper,
) -> Result<(), CommandError> {
    let git_repo = git::get_git_repo(workspace_command.repo().store())?;

    // Try "upstream" first, then fall back to "origin"
    for remote in ["upstream", "origin"] {
        let ref_name = format!("refs/remotes/{remote}/HEAD");
        if let Some(reference) = git_repo
            .try_find_reference(&ref_name)
            .map_err(internal_error)?
        {
            // Found a HEAD reference for this remote. Even if we can't parse it,
            // we should stop here and not try other remotes because it doesn't
            // really make sense if "origin" were to be set as the default if we
            // know "upstream" exists.
            if let Some(reference_name) = reference.target().try_name()
                && let Some((GitRefKind::Bookmark, symbol)) =
                    str::from_utf8(reference_name.as_bstr())
                        .ok()
                        .and_then(|name| parse_git_ref(name.as_ref()))
            {
                // TODO: Can we assume the symbolic target points to the same remote?
                let symbol = symbol.name.to_remote_symbol(remote.as_ref());
                write_repository_level_trunk_alias(ui, workspace_command.repo_path(), symbol)?;
            }
            return Ok(());
        }
    }

    Ok(())
}

fn print_trackable_remote_bookmarks(ui: &Ui, view: &View) -> io::Result<()> {
    let remote_bookmark_symbols = view
        .bookmarks()
        .filter(|(_, bookmark_target)| bookmark_target.local_target.is_present())
        .flat_map(|(name, bookmark_target)| {
            bookmark_target
                .remote_refs
                .into_iter()
                .filter(|&(_, remote_ref)| !remote_ref.is_tracked())
                .map(move |(remote, _)| name.to_remote_symbol(remote))
        })
        .collect_vec();
    if remote_bookmark_symbols.is_empty() {
        return Ok(());
    }

    if let Some(mut formatter) = ui.status_formatter() {
        writeln!(
            formatter.labeled("hint").with_heading("Hint: "),
            "The following remote bookmarks aren't associated with the existing local bookmarks:"
        )?;
        for symbol in &remote_bookmark_symbols {
            write!(formatter, "  ")?;
            writeln!(formatter.labeled("bookmark"), "{symbol}")?;
        }
        writedoc!(
            formatter.labeled("hint").with_heading("Hint: "),
            "
            Run the following command to keep local bookmarks updated on future pulls:
              jj bookmark track {syms}
            ",
            syms = remote_bookmark_symbols.iter().join(" ")
        )?;
    }
    Ok(())
}
